package com.emc.esu.api.rest
{
	import com.adobe.net.URI;
	import com.emc.esu.api.EsuApi;
	import com.emc.esu.api.EsuCallbackState;
	import com.emc.esu.api.EsuError;
	import com.hurlant.crypto.Crypto;
	import com.hurlant.crypto.hash.HMAC;
	
	import flash.events.Event;
	import flash.events.HTTPStatusEvent;
	import flash.events.IOErrorEvent;
	import flash.events.SecurityErrorEvent;
	import flash.net.URLLoader;
	import flash.net.URLLoaderDataFormat;
	import flash.net.URLRequest;
	import flash.net.URLRequestHeader;
	import flash.net.URLRequestMethod;
	import flash.utils.ByteArray;
	import flash.utils.Dictionary;
	
	import mx.formatters.DateFormatter;
	import mx.logging.ILogger;
	import mx.logging.Log;
	import mx.messaging.messages.HTTPRequestMessage;
	import mx.utils.ArrayUtil;
	import mx.utils.Base64Encoder;
	
	/**
	 * Handles REST requests to the ESU server.  Computes the signature,
	 * creates the URLLoader and handles the asynchronous responses.
	 */
	public class EsuRestRequestHttp
	{
		private static const DATE_FORMAT:String = "EEE, DD MMM YYYY JJ:NN:SS";
		
		// Keeps track of running requests and ensures they don't get garbage collected.
		private static var runningRequests:Array = new Array();
		
		private var resource:String;
		private var uri:URI;
		private var headers:Dictionary;
		private var responseProcessor:Function;
		private var callback:Function;
		private var userState:Object;
		private var loader:URLLoader;
		private var responseHeaders:Object;
		private var requestHeaders:Array;
		private var requestData:ByteArray;
		private var responseData:ByteArray;
		private var method:String;
		private var secret:ByteArray;
		private var esu:EsuApi;
		private var connectTimeout:int;
		private var rawHeaders:String;
		private var complete:Boolean = false;
		private var privateState:Object;
		private var responseCode:int = 0;
		private var internalCopyResponse:Boolean;
				
		private static var logger:ILogger = Log.getLogger("EsuApi");
		
		/**
		 * Creates a new EsuRestRequest
		 * @param resource the resource being accessed
		 * @param uri the full URI to the resource
		 */
		public function EsuRestRequestHttp( resource:String, uri:URI, connectTimeout:int, internalCopyResponse:Boolean ) {
			this.resource = resource;
			this.uri = uri;
			this.internalCopyResponse = internalCopyResponse;
			this.headers = new Dictionary();
			this.loader = new URLLoader();
			
			this.loader.dataFormat = URLLoaderDataFormat.BINARY;
			//this.connectTimeout = connectTimeout;
			//this.loader.requestTimeout = connectTimeout/1000.0;
		}
		
		/**
		 * Adds a header to the request
		 * @param name the header name (e.g. x-emc-uid)
		 * @param value the header's value
		 */
		public function addHeader( name:String, value:String ):void {
			headers[name]=value;
		}
		
		/**
		 * Sets the data that will be sent as the body of the request.
		 * @param data the request data
		 */
		public function setRequestData( data:ByteArray ):void {
			requestData = data;
		}
		
		/**
		 * Sets the callback function.  This function will be invoked when
		 * the request and response have completed.  This function should
		 * have the signature 
		 * <code>function( state:EsuCallbackState ):void</code>
		 * @param cb The callback function
		 */
		public function setCallback( cb:Function ):void {
			callback = cb;
		}
		
		/**
		 * Sets the HTTP method for the request.  This is generally
		 * one of the following: GET, POST, PUT, DELETE
		 * @param emthod the HTTP method
		 */
		public function setMethod( method:String ):void {
			this.method = method;
		}
		
		/**
		 * Sets the optional response processor.  When set, this function will
		 * be invoked after the response to generate the response object passed
		 * to the callback function.  This function should have the signature:
		 * <code>function( request:EsuRestRequest ):Object</code>.
		 * @param responseProcessor the response processing function.
		 */
		public function setResponseProcessor( responseProcessor:Function ):void {
			this.responseProcessor = responseProcessor;
		}
		
		/**
		 * Sets an optional user state object.  This object will
		 * be passed to the callback function in the EsuCallbackState object.
		 * @param state the state object.
		 */
		public function setUserState( state:Object ):void {
			this.userState = state;
		}
		
		/**
		 * Gets the optional user state object.
		 */
		public function getUserState():Object {
			return this.userState;
		}
		
		/**
		 * Sets the EsuApi object that created this request.  This
		 * will be passed to the callback function in the EsuCallbackState
		 * object.
		 * @param esu the EsuApi that created this request.
		 */
		public function setEsuApi( esu:EsuApi ):void {
			this.esu = esu;
		}
		
		/**
		 * Gets the response headers.  This is an object
		 * whose properties are set for each response header. e.g.
		 * <code>getResponseHeaders()["Content-Length"]</code>
		 * @return the response headers
		 */
		public function getReponseHeaders():Object {
			return responseHeaders;
		}
		
		public function getRawHeaders():String {
			return rawHeaders;
		}
		
		/**
		 * Gets the body of the response.  If the response has
		 * no data (like a DELETE request) the byte array will
		 * be zero length.
		 */
		public function getReponseData():ByteArray {
			return responseData;
		}
		
		/**
		 * Sets the shared secret used in the HMAC-SHA1 signature.
		 * @param secret the shared secret
		 */
		public function setSecret( secret:ByteArray ):void {
			this.secret = secret;
		}
				
		public function setPrivateState( obj:Object ):void {
			this.privateState = obj;
		}
		
		public function getPrivateState():Object {
			return this.privateState;
		}
		
		/**
		 * Sends the request
		 */
		public function send():void {
			// Set the method.
			var request:URLRequest = new URLRequest();
			request.url = uri.toString();
			

			request.method = URLRequestMethod.POST;
			request.data = requestData;
			
			// Set the Date header
			var date:Date = new Date();
			// converts the Date to UTC by adding or subtracting the time zone offset
			var offsetMilliseconds:Number = date.getTimezoneOffset() * 60 * 1000;
			date.setTime(date.getTime() + offsetMilliseconds);
			
			// Format it
			var formatter:DateFormatter = new DateFormatter();
			formatter.formatString = DATE_FORMAT;
			
			headers["x-emc-date"] = formatter.format( date ) + " UTC";
			
			// Sign the request
			signRequest();
			
			//loader.requestHeaders = requestHeaders;
			request.requestHeaders = requestHeaders;
			
			/*
			* These headers are added to provide compatability with Flex.
			* They require that the modules mod_rest_flex_compat and
			* mod_rest_override be installed on the apache web service
			* nodes.
			*/
			if( method != "POST" ) {
				request.requestHeaders.push(new URLRequestHeader("X-Http-Method-Override", method ));
			}
			
			if( requestData == null || requestData.length < 1 ) {
				// Another bit of nonsense: Flex changes POST into GET if there's
				// a zero-length body.  We need to request that the body is truncated.  We MUST
				// use POST too, since input headers are ignored on GETs.
				if( requestData == null ) {
					requestData = new ByteArray();
				}
				requestData.writeUTFBytes( "DummyBodyForFlex\r\n" );
				requestData.position = 0;
				request.data = requestData;
				request.requestHeaders.push(new URLRequestHeader("X-Rest-Flex-Truncate-Request", "true"));
			}
			
			request.requestHeaders.push(new URLRequestHeader("X-Rest-Flex-Compat", "true" ));
			
			loader.addEventListener( HTTPStatusEvent.HTTP_STATUS, onHttpStatus );
			loader.addEventListener( IOErrorEvent.IO_ERROR, onIoError );
			loader.addEventListener( SecurityErrorEvent.SECURITY_ERROR, onSecurityError );
			loader.addEventListener( Event.COMPLETE, onComplete );
			
			// Add this request to the array of running requests.  This ensures that
			// a pointer to this object is kept around so it doesn't get garbage
			// collected while running.  If we don't do this, periodically these
			// objects will 'disappear' in the middle of a request, closing the
			// socket and causing the callback function to never get called.
			runningRequests.push( this );
			loader.load( request );
		}
		
		/**
		 * Removes the request from the array of running requests
		 * @param req the request to remove.
		 */
		private static function removeRequest( req:EsuRestRequestHttp ):void {
			var index:int = ArrayUtil.getItemIndex( req, runningRequests );
			if( index == -1 ) {
				logger.debug( "Could not remove request: " + req + ".  Not found in runningRequests." );				
			} else {
				runningRequests.splice( index, 1 );
			}
			logger.debug( "Request count " + getRequestCount() );
		}
		
		/**
		 * Returns the number of requests that are currently running.
		 */
		private static function getRequestCount():int {
			return runningRequests.length;
		}
		
		/**
		 * Handles HTTP status events.  We currently don't do anything here.
		 */
		private function onHttpStatus( event:HTTPStatusEvent ):void {
			logger.debug( "onHttpStatus: " + uri );
			logger.debug( "onHttpStatus: " + event.status );
			if( event.status > 399 ) {
				this.responseCode = event.status;
			}
		}
		
		/**
		 * Handles IO errors.  Fail the request.
		 */
		private function onIoError( event:IOErrorEvent ):void {
			logger.debug( "onIoError: " + uri );
			
			if( this.responseCode > 0 ) {
				// Notify the callback
				callback( new EsuCallbackState( this.esu, this.method, false, 
					new EsuError( "HTTP error: " + this.responseCode, null, this.responseCode ), this.userState ) );
				removeRequest( this );
			} else {
				// Notify the callback
				callback( new EsuCallbackState( this.esu, this.method, false, 
					new EsuError( "IOError error during request: " + event.text ), this.userState ) );
				removeRequest( this );				
			}
		}
		
		/**
		 * Handles security errors opening the socket.  Fail the request.
		 */
		private function onSecurityError( error:SecurityErrorEvent ):void {
			logger.debug( "onSecurityError: " + uri );
			
			// Sometimes the security error comes after the request completes
			if( complete ) {
				return;
			}
			
			// Notify the callback
			callback( new EsuCallbackState( this.esu, this.method, false, 
				new EsuError( "Security error during request: " + error.text ), this.userState ) );
			removeRequest( this );
		}
		
		/**
		 * Called when the socket is complete.  Check for errors, parse
		 * the response, and notify the callback function.
		 */
		private function onComplete( event:Event ):void {
			logger.debug( "onComplete: " + uri );
			// Copy over headers and data
			
			// Headers should be in response data
			responseData = ByteArray( loader.data );
			
			// Special case for zero bytes
			var byteCount:Number = 0;
			if(responseData.length == 0) {
				byteCount = 0;
			} else {
				byteCount = new Number(readLine(responseData));
			}
			var headerData:String = responseData.readUTFBytes( byteCount );		
			responseHeaders = parseResponseHeaders(headerData);
			if( internalCopyResponse ) {
				var newResponse:ByteArray = new ByteArray();
				newResponse.writeBytes( responseData, responseData.position );
				newResponse.position = 0;
				responseData = newResponse;
			}
			complete = true;
			
			// Check status code
//			if( loader.httpStatus > 299 ) {
//				// See if there is a request body.  If so, try to parse
//				// an error message out of the XML.
//				processError();
//				return;
//			} else {
//				// Success. handle the response.
			var response:Object = null;
			if( responseProcessor != null ) {
				try {
					response = responseProcessor( this );
				} catch( e:EsuError ) {
					// Pass the error to the callback
					callback( new EsuCallbackState( this.esu, this.method, false, e, this.userState ) );
					return;
				} catch( e:Error ) {
					callback( new EsuCallbackState( this.esu, this.method, false, new EsuError( "Error processing response: " + e.message, e ), this.userState ) );
					return;
				}
			}
				
			// Invoke the callback.
			logger.debug( "Success.  Firing callback" );
			callback( new EsuCallbackState( this.esu, this.method, true, null, this.userState, response ) );
			
			removeRequest( this );
		}
		
		/**
		 * Handles error responses.  Attempt to parse an error message and code
		 * out of the body.  If that is not possible, use the HTTP status code
		 * and status message for the error.
		 */
		private function processError():void {
			if( responseData != null && responseHeaders["Content-Length"] != null && Number(responseHeaders["Content-Length"]) > 0 ) {
				responseData.position = 0;
				var responseStr:String = responseData.readUTFBytes( Number(responseHeaders["Content-Length"]) );
				
				try {
					var responseXml:XML = new XML( responseStr );
					
					var message:String = responseXml..Message.toString();
					var code:String = responseXml..Code.toString();
					
					logger.debug( "message: " + message + " code: " + code );
					
					if( message != null && code != null && message.length > 0 && code.length > 0 ) {
						callback( new EsuCallbackState( this.esu, this.method, false, new EsuError( 
							message, null, Number(code) ), this.userState ) );
						removeRequest( this );
						return;
					}
				} catch( e:Error ) {
					logger.debug( "couldn't parse XML response: " + e.message );
				}
			}
			
			// Else, just throw the HTTP status information.
//			callback( new EsuCallbackState( this.esu, this.method, false, 
//				new EsuError( "Request Failed: " + loader.statusLine, null, loader.httpStatus ), this.userState ) );
			removeRequest( this );
			
		}
		
		/**
		 * Reads a line of text from the ByteArray.  Lines are
		 * terminated with \r\n
		 */
		private function readLine( data:ByteArray ):String {
			var str:String = "";
			
			while(true) {
				var ch:String = data.readUTFBytes(1);
				if( ch == "\r" && data.readUTFBytes(1) == "\n" ) {
					return str;
				}
				if( ch == "\n" ) {
					return str;
				}
				str += ch;
			}
			return null;
		}
		
		/**
		 * Parses the response header string per HTTP specifications
		 */
		private function parseResponseHeaders( headerData:String ):Object {
			var headers:Object = new Object();
			var lastHeader:String = null;
			
			var lines:Array = headerData.split("\n");
			for each( var line:String in lines ){
				if( line == null || line.length < 1 ) {
					continue;
				}
				if( line.charAt( line.length-1 ) == '\r' ) {
					// Strip \r
					line = line.substr(0, line.length-1);
				}
				if( line.substr(0, 1) == " " ) {
					// Continuation header.  Append to last value.
					if( lastHeader != null ) {
						if( headers[lastHeader] is Array ) {
							var headerArr:Array = headers[lastHeader];
							headerArr[headerArr.length-1] += line;
						} else {
							headers[lastHeader] += line;
						}
					}
				} else {
					// Split on colon, strip first space from value.
					var sep:int = line.indexOf(":");
					if( sep == -1 ) {
						throw new EsuError( "Could not parse header line: " + line );
					}
					var name:String = line.substr(0, sep);
					var value:String = line.substr(sep+1);
					if( value.substr(0,1) == " " ) {
						value = value.substr(1);
					}
					if( headers.hasOwnProperty(name) ) {
						// Already set.  Convert to array if needed.
						if( headers[name] is Array ) {
							var headerArr3:Array = headers[name] as Array;
							headerArr3.push(line);
						} else {
							var headerArr2:Array = new Array();
							headerArr2.push(headers[name]);
							headerArr2.push(value);
							headers[name] = headerArr2;
						}
					} else {
						headers[name] = value;
					}
				}
			}
			return headers;
		}
		
		/**
		 * Generates the HMAC-SHA1 signature used to authenticate the request using
		 * the Flex security APIs.
		 */
		private function signRequest():void {
			// Build the string to hash.
			var hashStr:String = "";
			hashStr += method + "\n";
			
			// If content type exists, add it.  Otherwise add a blank line.
			if( headers[ "Content-Type" ] != null ) {
				logger.debug( "Content-Type: " + headers[ "Content-Type" ] );
				hashStr += headers[ "Content-Type" ] + "\n";
			} else {
				hashStr += "\n";
			}
			
			// If the range header exists, add it.  Otherwise add a blank line.
			if( headers[ "Range" ] != null ) {
				hashStr += headers[ "Range" ] + "\n";
			} else {
				hashStr += "\n";
			}
			
			// Add the current date and the resource.
			hashStr += headers[ "x-emc-date" ] + "\n" +
				resource.toLowerCase();
			if( uri.query != null && uri.query.length > 0 ) {
				hashStr += "?" + uri.query;
			}
			hashStr += "\n";
			
			// Do the 'x-emc' headers.  The headers must be hashed in alphabetic
			// order and the values must be stripped of whitespace and newlines.
			var keys:Array = new Array(); 
			var newheaders:Dictionary = new Dictionary();
			
			// Extract the keys and values
			for( var key:String in headers ) {
				if( key.indexOf( "x-emc" ) == 0 ) {
					keys.push( key.toLowerCase() );
					newheaders[ key.toLowerCase() ] = normalizeSpace(headers[ key ].replace( /\n/g, "" ));
				}
			}
			
			// Sort the keys and add the headers to the hash string.
			keys.sort();
			
			var first:Boolean = true;
			for each( var key2:String in keys ) {
				if( !first ) {
					hashStr += "\n";
				} else {
					first = false;
				}
				//this.logger.debug( "xheader: " . k . "." . newheaders[k] );
				hashStr += key2 + ':' + newheaders[ key2 ];
			}
			
			var hashOut:String = sign( hashStr, this.secret );
			
			// Can set all the headers, etc now.
			requestHeaders = new Array();
			for( var name:String in headers ) {
				// Put the 'Range' header in 'X-Emc-Range'.  The REST filter will
				// fix it later.
				var value:String = headers[ name ];
				if( name.toLowerCase() == "range" ) {
					name = "x-http-range";
				}
				requestHeaders.push( new URLRequestHeader( name, value ) );
			}
			
			// Set the signature header
			requestHeaders.push( new URLRequestHeader( "x-emc-signature", hashOut ) );
		}
		
		private function normalizeSpace( value:String ):String {
			var len:int = value.length;
			
			while(true) {
				value = value.replace( /  /g, " " );
				if( value.length == len ) {
					return value;
				}
				len = value.length;
			}
			
			return null;
		}
		
		/**
		 * Signs a string with the given key
		 * 
		 * @param str the string to sign
		 * @param key the key to sign with
		 * @return the base-64 encoded signature value
		 */
		public static function sign( str:String, key:ByteArray ):String {
			logger.debug( "Hashing: \n" + str );
			
			// Compute the signature hash
			//var hashOut:String = hmacSha1( secret, hashStr );
			var data:ByteArray = new ByteArray();
			data.writeUTFBytes( str );
			var hmacSha1:HMAC = Crypto.getHMAC( "sha1" );
			var sigBytes:ByteArray = hmacSha1.compute( key, data );
			
			// Base-64 encode the signature
			var encoder:Base64Encoder  = new Base64Encoder;
			encoder.insertNewLines = false;
			encoder.encodeBytes( sigBytes, 0, sigBytes.length );
			var hashOut:String = encoder.toString();
			
			logger.debug( "Hash: " + hashOut );
			return hashOut;
		}
	}
}